/*
 * Tigase XMPP Server - The instant messaging server
 * Copyright (C) 2004 Tigase, Inc. (office@tigase.com)
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, version 3 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program. Look for COPYING file in the top folder.
 * If not, see http://www.gnu.org/licenses/.
 */
package tigase.xmpp.impl.roster;

import tigase.annotations.TigaseDeprecated;
import tigase.db.TigaseDBException;
import tigase.db.UserNotFoundException;
import tigase.db.UserRepository;
import tigase.server.PolicyViolationException;
import tigase.xml.DomBuilderHandler;
import tigase.xml.Element;
import tigase.xml.SimpleParser;
import tigase.xml.SingletonFactory;
import tigase.xmpp.NotAuthorizedException;
import tigase.xmpp.XMPPResourceConnection;
import tigase.xmpp.XMPPSession;
import tigase.xmpp.jid.BareJID;
import tigase.xmpp.jid.JID;

import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Describe class RosterFlat here.
 * <br>
 * Created: Tue Feb 21 18:05:53 2006
 *
 * @author <a href="mailto:artur.hefczyc@tigase.org">Artur Hefczyc</a>
 */
public class RosterFlat
		extends RosterAbstract {

	private static final Logger log = Logger.getLogger(RosterFlat.class.getName());
	private static final SimpleParser parser = SingletonFactory.getParserInstance();

	private final SimpleDateFormat formatter;

	{
		this.formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
		this.formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
	}

	public static boolean addBuddy(RosterElement relem, Map<BareJID, RosterElement> roster) {
		if (roster.size() < maxRosterSize) {
			roster.put(relem.getJid().getBareJID(), relem);

			return true;
		}

		return false;
	}

	public static boolean parseRosterUtil(String roster_str, Map<BareJID, RosterElement> roster,
										  XMPPResourceConnection session) {
		boolean result = false;
		DomBuilderHandler domHandler = new DomBuilderHandler();

		parser.parse(domHandler, roster_str.toCharArray(), 0, roster_str.length());

		Queue<Element> elems = domHandler.getParsedElements();

		if ((elems != null) && (elems.size() > 0)) {
			for (Element elem : elems) {
				try {
					RosterElement relem = new RosterElement(elem);

					result |= relem.isModified();
					if (!addBuddy(relem, roster)) {
						break;
					}
				} catch (Exception e) {
					log.log(Level.WARNING, "Can't load roster element: " + elem, e);
				}
			}
		}

		return result;
	}

	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public RosterElement addTempBuddy(JID buddy, XMPPResourceConnection session)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElementInstance(buddy.copyWithoutResource(), null, null, session);

		relem.setPersistent(false);
		relem.setSubscription(null);
		addBuddy(relem, getUserRoster(session));

		if (log.isLoggable(Level.FINEST)) {
			log.log(Level.FINEST, "Added temporary buddy to roster: {0}, name: {1}, item: {2}",
					new Object[]{relem.getJid(), relem.getName(), relem.getRosterItem(), relem.toString()});
		}
		return relem;
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public void addBuddy(XMPPResourceConnection session, JID buddy, String name, String[] groups, SubscriptionType subscription, String otherData)
			throws NotAuthorizedException, TigaseDBException, PolicyViolationException {
		this.addBuddy(session, buddy, name, groups, subscription, null, otherData);
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public void addBuddy(XMPPResourceConnection session, JID buddy, String name, String[] groups, SubscriptionType subscription, String mixParticipantId, String otherData)
			throws NotAuthorizedException, TigaseDBException, PolicyViolationException {

		// String buddy = JIDUtils.getNodeID(jid);
		RosterElement relem = getRosterElement(session, buddy);

		if (relem == null) {
			Map<BareJID, RosterElement> roster = getUserRoster(session);

			relem = getRosterElementInstance(buddy, name, groups, session);
			if (emptyNameAllowed && (name == null || name.isEmpty())) {
				relem.setName(null);
			} else if (name == null || name.isEmpty()) {
				String n = buddy.getLocalpart();
				if ((n == null) || n.trim().isEmpty()) {
					n = buddy.getBareJID().toString();
				}
				relem.setName(n);
			} else {
				relem.setName(name);
			}
			relem.setOtherData(otherData);
			if (log.isLoggable(Level.FINEST)) {
				log.log(Level.FINEST, "1. Added buddy to roster: {0}, name: {1}, item: {2}",
						new Object[]{relem.getJid(), relem.getName(), relem.getRosterItem()});
			}
			if (subscription != null) {
				relem.setSubscription(subscription);
			}
			relem.setMixParticipantId(mixParticipantId);
			if (addBuddy(relem, roster)) {
				saveUserRoster(session);
				// notify that roster element was changed!
			} else {
				throw new PolicyViolationException("Too many elements in the user roster. Limit: " + maxRosterSize);
			}
			if (log.isLoggable(Level.FINEST)) {
				log.log(Level.FINEST, "2. Added buddy to roster: {0}, name: {1}, item: {2}",
						new Object[]{relem.getJid(), relem.getName(), relem.getRosterItem()});
			}
		} else {
			if (emptyNameAllowed && (name == null || name.isEmpty())) {
				relem.setName(null);
			} else if (name == null || name.isEmpty()) {
				String n = buddy.getLocalpart();
				if ((n == null) || n.trim().isEmpty()) {
					n = buddy.getBareJID().toString();
				}
				relem.setName(n);
			} else {
				relem.setName(name);
			}

			// Hm, as one user reported this make it impossible to remove the user
			// from
			// all groups. Let's comments it out for now to see how it works.
			// Probably added this some time ago , before RosterFlat to prevent NPE.
			// if ((groups != null) && (groups.length > 0)) {
			relem.setGroups(groups);

			// }
			relem.setMixParticipantId(mixParticipantId);
			relem.setPersistent(true);
			if (subscription != null) {
				relem.setSubscription(subscription);
			}
			saveUserRoster(session);
			// notify that roster element was changed!
			if (log.isLoggable(Level.FINEST)) {
				log.log(Level.FINEST, "Updated buddy in roster: {0}", buddy);
			}
		}
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public boolean addBuddyGroup(XMPPResourceConnection session, JID buddy, String[] groups)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem != null) {
			relem.addGroups(groups);

			// Intentionally not saving the roster here.
			// At the moment it is only used to combine dynamic roster with the
			// static roster in case a contact exist in both but in a different
			// group.
			return true;
		} else {
			return false;
		}
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public boolean containsBuddy(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		return (relem != null) && relem.isPersistent();
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public JID[] getBuddies(XMPPResourceConnection session) throws NotAuthorizedException, TigaseDBException {
		Map<BareJID, RosterElement> roster = getUserRoster(session);

		if (roster.size() == 0) {
			return null;
		}

		JID[] result = new JID[roster.size()];
		int idx = 0;

		for (RosterElement rosterElement : roster.values()) {
			result[idx++] = rosterElement.getJid();
		}

		// TODO: this sorting should be optional as it may impact performance
		Arrays.sort(result, new RosterElemComparator(roster));

		return result;

	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public String[] getBuddyGroups(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem == null) {
			return null;
		} else {
			return relem.getGroups();
		}
	}

	private Element getBuddyItem(RosterElement relem) {
		return relem.getRosterItem();
	}

	private Element getBuddyItem(final XMPPResourceConnection session, RosterElement relem) {
		Element item = getBuddyItem(relem);
		if (Boolean.TRUE.equals(session.getSessionData("urn:xmpp:mix:roster:0"))) {
			String participantId = relem.getMixParticipantId();
			if (participantId != null) {
				item.addChild(new Element("channel", new String[] { "xmlns", "participant-id" }, new String[] { "urn:xmpp:mix:roster:0", participantId }));
			}
		}
		return item;
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public Element getBuddyItem(final XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem == null) {
			return null;
		} else {
			return getBuddyItem(session, relem);
		}
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public String getBuddyName(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem == null) {
			return null;
		} else {
			return relem.getName();
		}
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public SubscriptionType getBuddySubscription(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		// either we don't have such contact or it's not persistend in which case it shouldn't
		// have subscription -- this is simpler solution instead of reworking whole RosterElement
		// to allow sub==null
		if (relem == null || (!relem.isPersistent())) {
			return null;
		} else {
			return relem.getSubscription();
		}

		// return SubscriptionType.both;
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public String getMixParticipantId(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem == null) {
			return null;
		} else {
			return relem.getMixParticipantId();
		}
	}

	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public RosterElement getRosterElementInstance(JID buddy, String name, String[] groups,
												  XMPPResourceConnection session) {
		return new RosterElement(buddy.copyWithoutResource(), name, groups);
	}

	@Override
	public List<Element> getRosterItems(XMPPResourceConnection session)
			throws NotAuthorizedException, TigaseDBException {
		LinkedList<Element> items = new LinkedList<Element>();
		Map<BareJID, RosterElement> roster = getUserRoster(session);

		for (RosterElement relem : roster.values()) {

			// Skip temporary roster elements added only for online presence tracking
			// from dynamic roster
			if (relem.isPersistent() && !SubscriptionType.none_pending_in.equals(relem.getSubscription())) {
				items.add(getBuddyItem(session, relem));
			}
		}

		return items;
	}

	@Override
	public boolean isRosterLoaded(XMPPResourceConnection session) {
		return session.getCommonSessionData(ROSTER) != null;
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public boolean isOnline(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		return (relem != null) && relem.isOnline();
	}

	public boolean parseRoster(String roster_str, Map<BareJID, RosterElement> roster, XMPPResourceConnection session) {
		return parseRosterUtil(roster_str, roster, session);
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public boolean presenceSent(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		return (relem != null) && relem.isPresence_sent();
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public boolean removeBuddy(XMPPResourceConnection session, JID jid)
			throws NotAuthorizedException, TigaseDBException {
		Map<BareJID, RosterElement> roster = getUserRoster(session);

		if (log.isLoggable(Level.FINEST)) {
			log.log(Level.FINEST, "Removing roster buddy: {0}, before removal: {1}", new Object[]{jid, roster});
		}
		roster.remove(jid.getBareJID());
		if (log.isLoggable(Level.FINEST)) {
			log.log(Level.FINEST, "Removing roster buddy: {0}, after removal: {1}", new Object[]{jid, roster});
		}
		saveUserRoster(session);
		// notify that roster element was changed!

		return true;
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public void setBuddyName(XMPPResourceConnection session, JID buddy, String name)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem != null) {
			if (log.isLoggable(Level.FINEST)) {
				log.log(Level.FINEST, "Setting name: ''{0}'' for buddy: {1}", new Object[]{name, buddy});
			}
			if (emptyNameAllowed && (name == null || name.isEmpty())) {
				relem.setName(null);
			} else if (name == null || name.isEmpty()) {
				String n = buddy.getLocalpart();
				if ((n == null) || n.trim().isEmpty()) {
					n = buddy.getBareJID().toString();
				}
				relem.setName(n);
			} else {
				relem.setName(name);
			}
			saveUserRoster(session);
			// notify that roster element was changed!

		} else {
			log.log(Level.WARNING, "Setting buddy name for non-existen contact: {0}", buddy);
		}
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public void setBuddySubscription(XMPPResourceConnection session, SubscriptionType subscription, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem != null) {
			relem.setSubscription(subscription);
			saveUserRoster(session);
			// notify that roster element was changed!

		} else {
			log.log(Level.WARNING, "Missing roster contact for subscription set: {0}", buddy);
		}
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public void setOnline(XMPPResourceConnection session, JID buddy, boolean online)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem == null) {
			relem = addTempBuddy(buddy, session);
		}
		relem.setOnline(buddy.getResource(), online);
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public void setPresenceSent(XMPPResourceConnection session, JID buddy, boolean sent)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement relem = getRosterElement(session, buddy);

		if (relem == null) {
			relem = addTempBuddy(buddy, session);
		}
		relem.setPresence_sent(sent);
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public RosterElement getRosterElement(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		Map<BareJID, RosterElement> roster = getUserRoster(session);

		return roster.get(buddy.getBareJID());
	}

	public Map<BareJID, RosterElement> loadUserRoster(XMPPResourceConnection session)
			throws NotAuthorizedException, TigaseDBException {

		// In most times we just read from this data structure
		// From time to time there might be some modification, posibly concurrent
		// very unlikely by more than one thread
		Map<BareJID, RosterElement> roster = new ConcurrentHashMap<BareJID, RosterElement>(100, 0.25f, 1);

		session.putCommonSessionData(ROSTER, roster);

		String roster_str = session.getData(null, ROSTER, null);

		if (log.isLoggable(Level.FINEST)) {
			log.log(Level.FINEST, "Loaded user {1} roster: {0}", new Object[]{roster_str, session.getjid()});
		}
		if ((roster_str != null) && !roster_str.isEmpty()) {
			boolean modified = parseRoster(roster_str, roster, session);

			if (modified) {
				saveUserRoster(session);
				// notify that roster element was changed!
			}
		}

		return roster;
	}

	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public Function<JID, RosterElement> rosterElementProvider(XMPPResourceConnection session)
			throws NotAuthorizedException, TigaseDBException {
		XMPPSession commonSession = session.getParentSession();
		if (commonSession == null || !session.isAuthorized()) {
			throw new NotAuthorizedException("No parent session set!");
		}
		getUserRoster(session);

		return (jid) -> {
			Map<BareJID, RosterElement> roster = (Map<BareJID, RosterElement>) commonSession.getCommonSessionData(
					ROSTER);
			return roster == null ? null : roster.get(jid.getBareJID());
		};
	}

	@Override
	@Deprecated
	@TigaseDeprecated(since = "8.2.0", removeIn = "9.0.0", note = "We should only allow BareJIDs")
	public Element getCustomChild(XMPPResourceConnection session, JID buddy)
			throws NotAuthorizedException, TigaseDBException {
		RosterElement rel = getRosterElement(session, buddy);

		if (rel != null && rel.getLastSeen() > RosterElement.INITIAL_LAST_SEEN_VAL) {
			String stamp;
			synchronized (formatter) {
				stamp = formatter.format(new Date(rel.getLastSeen()));
			}

			return new Element("delay", new String[]{"stamp", "xmlns"}, new String[]{stamp, "urn:xmpp:delay"});
		}

		return null;
	}

	@Override
	public void logout(XMPPResourceConnection session) {
		try {
			if (session.isAuthorized() && isModified(session)) {
				saveUserRoster(session);
				// notify that roster element was changed!
			}
		} catch (NotAuthorizedException | TigaseDBException ex) {
			if (ex.getCause() instanceof UserNotFoundException) {
				log.log(Level.FINEST, "Error logging out user, user already removed? " + session, ex);
			} else {
				log.log(Level.WARNING, "Error logging out user", ex);
			}
		}
	}

	public boolean isModified(XMPPResourceConnection session) throws NotAuthorizedException, TigaseDBException {
		Map<BareJID, RosterElement> roster = getUserRoster(session);
		boolean result = false;

		if (roster != null) {
			for (RosterElement rel : roster.values()) {
				result |= rel.isModified();
			}
		}

		return result;
	}

	@SuppressWarnings({"unchecked"})
	protected Map<BareJID, RosterElement> getUserRoster(XMPPResourceConnection session)
			throws NotAuthorizedException, TigaseDBException {
		Map<BareJID, RosterElement> roster = null;

		// The method can be called from different plugins concurrently.
		// If the roster is not yet loaded from DB this causes concurent
		// access problems
		synchronized (session) {
			roster = (Map<BareJID, RosterElement>) session.getCommonSessionData(ROSTER);
			if (roster == null) {
				roster = loadUserRoster(session);
			}
			if (getBuddiesHash(session) == null) {
				updateRosterHash(session);
			}
		}

		return roster;
	}

	@Override
	public void modifyStoredRoster(UserRepository repository, BareJID owner,
								   Consumer<Map<BareJID, RosterElement>> modifyRoster) throws TigaseDBException {
		String rosterStr = repository.getData(owner, null, RosterAbstract.ROSTER, null);
		Map<BareJID, RosterElement> roster = new LinkedHashMap<>();
		if (rosterStr != null) {
			RosterFlat.parseRosterUtil(rosterStr, roster, null);
		}
		modifyRoster.accept(roster);
		StringBuilder sb = new StringBuilder();
		for (RosterElement relem : roster.values()) {
			sb.append(relem.getRosterElement().toString());
		}
		repository.setData(owner, null, RosterAbstract.ROSTER, sb.toString());
	}

	protected void saveUserRoster(XMPPResourceConnection session) throws NotAuthorizedException, TigaseDBException {
		Map<BareJID, RosterElement> roster = getUserRoster(session);
		StringBuilder sb = new StringBuilder(5000);

		for (RosterElement relem : roster.values()) {
			if (relem.isPersistent()) {
				sb.append(relem.getRosterElement().toString());
			}
			// here we could detect changed records, but is removed record changed??
			// no - item removed is gone!!!
		}
		if (log.isLoggable(Level.FINEST)) {
			log.log(Level.FINEST, "{0} | Saving user roster: {1}",
					new String[]{session.getBareJID().toString(), sb.toString()});
		}
		session.setData(null, ROSTER, sb.toString());

		// here we should record changes? but how to detect that change was made and what was changed?
		//
	}

	@Override
	protected void updateRosterItem(XMPPResourceConnection session, RosterModifiedEvent event)
			throws NotAuthorizedException, TigaseDBException {
		// apply changes got from notification item

		Map<BareJID, RosterElement> roster = getUserRoster(session);
		if (event.getSubscription() == SubscriptionType.remove) {
			roster.remove(event.getJid().getBareJID());
			return;
		}

		RosterElement element = roster.get(event.getJid().getBareJID());
		if (element == null) {
			element = new RosterElement(event.getJid(), event.getName(), event.getGroups());
			element.setSubscription(event.getSubscription());
			element.setMixParticipantId(event.getMixParticipantId());
			addBuddy(element, getUserRoster(session));
		} else {
			element.setName(event.getName());
			element.setGroups(event.getGroups());
			element.setSubscription(event.getSubscription());
			element.setMixParticipantId(event.getMixParticipantId());
		}

		super.updateRosterItem(session, event);
	}

	private class RosterElemComparator
			implements Comparator<JID> {

		private Map<BareJID, RosterElement> roster = null;

		private RosterElemComparator(Map<BareJID, RosterElement> roster) {
			this.roster = roster;
		}

		@Override
		public int compare(JID arg0, JID arg1) {
			double w0 = roster.get(arg0.getBareJID()).getWeight();
			double w1 = roster.get(arg1.getBareJID()).getWeight();

			return Double.compare(w0, w1);
		}
	}
}    // RosterFlat

